Skip to content

add compact layout counter-rotation and mode readout - #655

Draft
Kimblebee wants to merge 33 commits into
kim/adaptive/layout-orchestratorfrom
kim/adaptive/layout-rotation
Draft

Kimblebee wants to merge 33 commits into
kim/adaptive/layout-orchestratorfrom
kim/adaptive/layout-rotation

Conversation

@Kimblebee

Copy link
Copy Markdown
Collaborator

Stacked PR: Based on kim/adaptive/layout-orchestrator #653 (also merges kim/adaptive/debug-layout-readout #643 )

Summary

This PR joins the adaptive layout orchestrator track (kim/adaptive/layout-orchestrator) with the rotation and debug readout track (kim/adaptive/debug-layout-readout). Previously, horizontal <600dp or <480dp-tall compact windows (COMPACT / COMPACT_SLIM, such as 800×400dp horizontal split-screen panes or 740×360dp landscape phone windows) routed to CompactPortraitLayout without counter-rotating, stacking top bar, viewfinder, and bottom controls vertically inside 320–400dp of height. This change counter-rotates the compact UI chrome into portrait along the window's long axis while hosting the live viewfinder in an un-rotated outer layer (OuterViewfinderHost) so SurfaceView (ImplementationMode.EXTERNAL) and 10-bit HDR remain active across all postures. It also adds physical window-inset and display-cutout coordinate remapping, seamless rotation transitions, and live CameraLayoutMode / LocalActiveCaptureLayout telemetry in DebugLayoutReadout.

Key Changes

  • Counter-Rotation & Inset Modifiers (RotationModifiers.kt):
    • Added Modifier.counterRotateToPortrait(windowRotation) to swap width/height Constraints and counter-rotate landscape (Rotated90 / Rotated270) subtrees to canonical portrait (0°) via placeWithLayer, acting as a strict no-op for Natural and Rotated180.
    • Added PortraitWindowInsets / WindowInsets.toPortraitWindowInsets(windowRotation) to remap physical OS window edges into portrait coordinates independently of LayoutDirection.
    • Added LocalCompactCounterRotation (-90, 0, or +90) and SeamlessRotationAnimationEffect requesting ROTATION_ANIMATION_SEAMLESS on API 26+ while CompactPortraitLayout is active and restoring the prior window attribute on disposal.
  • Compact Subtree Counter-Rotation, Decoupled OuterViewfinderHost, & Inset Wiring (CompactPortraitLayout.kt, CaptureScreenComponents.kt, QuickSettingsScreen.kt):
    • Hosted viewfinder in OuterViewfinderHost (un-rotated host coordinate frame, outside BottomSheetScaffold(containerColor = Color.Transparent)) paired with a same-pass ExternalViewfinderProxy (ExternalViewfinderMetrics) inside the counter-rotated portrait chrome and shared Modifier.suppressBackgroundForModal(isModalOpen).
    • Added horizontal solvedBand placement in PreviewDisplay so 4:3 / 16:9 frames align cleanly between the counter-rotated top bar and capture controls under both Rotated270 (left top bar) and Rotated90 (right top bar), keeping ImplementationMode.EXTERNAL (SurfaceView) on all API > 24 devices.
    • Wrapped CompactPortraitLayout's BottomSheetScaffold and screenFlashOverlay in .counterRotateToPortrait(effectiveWindowRotation) whenever windowWidth > windowHeight, deriving re-anchored LocalDeviceRotation, LocalWindowRotation, and LocalGravityRotationState values for descendants, and applied .toPortraitWindowInsets(...) across status bar, cutout, navigation bar, and gesture insets.
  • 2D Display Cutout Remapping (CutoutAwareRow.kt):
    • Subscribed to cutoutInsets.getBottom(this) during measurement and updated resolveLocalCutoutRects to transform OS window DisplayCutout rects into local portrait LTR/RTL coordinates across all four DeviceRotation states (with view.width/view.height pass-1 fallbacks and landscape RTL span selection).
  • Orchestrator Plumbing, Side-Rail Isolation, & Debug Overlay Integration (CaptureLayout.kt, CaptureScaffold.kt, SideRailCaptureLayouts.kt, PreviewScreen.kt):
    • Threaded deviceRotation and windowRotation through CaptureScaffold and CaptureScaffoldLayout into CompactPortraitLayout, and enabled useLandscapeAspectRatio = true on PreviewDisplay.
    • Explicitly scoped LocalWindowRotation = DeviceRotation.Natural and LocalGravityRotationState = DefaultGravityRotationState in SideRailCaptureScaffold so FoldableLayout, TabletPortraitLayout, TabletLandscapeLayout, and CompactShortLayout never counter-rotate.
    • Wired LocalCameraLayoutMode.current.name and LocalActiveCaptureLayout.current into DebugLayoutReadoutState (replacing the pre-join "CaptureScaffoldLayout" placeholder).

Technical Notes

  • Geometry-Gated Counter-Rotation (windowWidth > windowHeight): Counter-rotation is driven by container aspect rather than raw windowRotation.isLandscape. A tall side-by-side split-screen pane on a rotated phone (380×760dp at Rotated90) stays unrotated (effectiveWindowRotation = Natural), whereas a wide horizontal split-screen pane on an upright device (800×400dp or 550×500dp at Natural) counter-rotates -90° CCW (Rotated270, placing TopBar on the left and Shutter on the right) and sets containerFrameRotation = Rotated90 so LocalGravityRotationState supplies +90° CW to keep child icons upright.
  • Why OuterViewfinderHost sits outside both .counterRotateToPortrait and BottomSheetScaffold:
    1. SurfaceView (ImplementationMode.EXTERNAL, required for 10-bit COLOR_MODE_HDR) punches through via SurfaceControl and ignores RenderNode rotationZ transforms; keeping OuterViewfinderHost un-rotated lets CameraX's SurfaceRequest orient frames directly on a hardware SurfaceView.
    2. Material 3 BottomSheetScaffold subcomposes its body via SubcomposeLayout during layout measurement; keeping movableViewfinder in a standard Layout outside BottomSheetScaffold keeps it in the primary composition pass across all 5 posture layouts, preventing DetectWindowColorModeChanges onCompletion from firing COLOR_MODE_DEFAULT during COMPACT <-> TABLET_LANDSCAPE transitions.
  • Center-Pivot Hit-Testing Invariant: Placing a portrait-measured w_c × h_c child at ((h_c - w_c) / 2, (w_c - h_c) / 2) inside a h_c × w_c layout aligns TransformOrigin.Center with (layoutWidth / 2, layoutHeight / 2), preserving pixel-exact Compose pointer input and semantics boundsInRoot after ±90° layer rotation.

Not Included Yet

  • Comes later in this series:
    • CameraX window-rotation sync (kim/adaptive/viewfinder-rotation): Wiring TransientSessionSettings.windowRotation to update Preview.targetRotation in place, frozen while recording.
    • Upright Content Gravity & Sticky 180° Retention (kim/adaptive/content-gravity): Applying .animateToUpright() / .rotateLayoutToGravity() across capture buttons, ZoomBar labels, ToggleSwitch icons, LandscapeTopEdgeBanner, Quick Settings landscape scroll/cross-fade, and CameraErrorDialog — plus sticky 180° gravity retention.
    • Wave 1 Leaf Placeholders: Vertical top-bar status pill (#649), vertical capture mode switch (#635), QuickSettingsOverlayCard container (#648), Tooltip polish (#622), and ZoomBar RTL polish (#644) remain unchanged from kim/adaptive/layout-orchestrator.

Testing

  • LayoutRotationTest (new): Robolectric Compose UI tests covering full-screen 740×360dp landscape phone counter-rotation (Rotated90 & Rotated270) with upright OuterViewfinderHost, 380×760dp portrait split-screen non-rotation on a rotated phone, 3 × 3 horizontal split-screen matrix (852×420dp, 800×400dp, 550×500dp × Natural / Rotated90 / Rotated270), non-rotation across FOLDABLE / TABLET_PORTRAIT / TABLET_LANDSCAPE / COMPACT_SHORT, CutoutAwareRow LTR/RTL cutout pill clearance, and uninterrupted 10-bit HLG_10_BIT COLOR_MODE_HDR across 412×915dp -> 800×400dp -> 1280×800dp -> 412×915dp.
  • PreviewDisplayTest: Verified horizontal solved-band alignment under LocalCompactCounterRotation.
  • RotationModifiersTest: Unit/Compose tests for counterRotateToPortrait (Natural, Rotated180, Rotated90, Rotated270, and unbounded width safety) and toPortraitWindowInsets across all 4 rotations in LTR and RTL.
  • CutoutAwareRowTest: Added pass-1 (rowBoundsInWindow == null) Rotated90 / Rotated270 cutout remapping assertions using fallbackViewWidth and fallbackViewHeight.
  • DebugLayoutReadoutTest: Verified DEBUG_LAYOUT_MODE_TAG renders cameraLayoutMode when populated by CaptureScaffoldLayout.

Kimblebee and others added 30 commits November 2, 2026 19:38
Add DebugLayoutReadoutState and internal DebugLayoutReadout composable
to display window container size in dp, display rotation degrees,
rotation-lock status, and active adaptive layout mode in DebugConsole,
falling back to live LocalWindowInfo container dimensions when explicit
posture state is omitted.

Also fix DebugTextBar applying its outer Row modifier three times to its
child Text nodes, configure Robolectric Compose unit testing for
:ui:debug, and add unit tests covering explicit/default readouts, live
window fallback, modifier scoping, and hidden-component behavior.
…viewDisplay

- Extract @VisibleForTesting internal calculateViewfinderSize with non-positive bounds guard and landscape aspect ratio selection when useLandscapeAspectRatio && maxWidth > maxHeight
- Add contentAlignment and useLandscapeAspectRatio trailing default parameters to PreviewDisplay
- Replace BoxWithConstraints with standard Box + Modifier.layout measurement and graphicsLayer draw-phase blink alpha/clip
- Stabilize gesture callbacks with rememberUpdatedState and migrate rememberTransformableState to non-deprecated centroid overload
- Enforce LayoutDirection.Ltr inside PreviewDisplay viewfinder scope so tap-to-focus coordinates and FocusMeteringIndicator never mirror in RTL
- Add PreviewDisplay @previews and Robolectric unit/Compose tests in PreviewDisplayTest
…yout-readout

# Conflicts:
#	ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt
# Conflicts:
#	ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt
…dow orientation

- Remove static android:screenOrientation="nosensor" from MainActivity in AndroidManifest.xml and declare orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden configChanges
- Add runtime resolveRequestedOrientation in MainActivity to lock full-screen compact phones (< 600dp) to SCREEN_ORIENTATION_NOSENSOR while using SCREEN_ORIENTATION_UNSPECIFIED in multi-window and on large screens (>= 600dp) to prevent 1:1 OS compatibility letterboxing
- Pass useLandscapeAspectRatio = true to PreviewDisplay in PreviewScreen so wide containers frame the viewfinder in landscape (4:3 / 16:9)
- Add unit tests in MainActivityOrientationTest
…/debug-layout-readout

# Conflicts:
#	feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt
#	ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt
…display-framing

# Conflicts:
#	feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt
#	ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt
…/debug-layout-readout

# Conflicts:
#	core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt
#	feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt
#	ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/DebouncedOrientationFlow.kt
…on, active layout readout, and recording orientation lock

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive layout and rotation overhaul to support seamless, posture-aware layouts across various form factors (compact phones, foldables, and tablets), including dynamic orientation locking during video recording and layout-level counter-rotation. The changes are well-supported by extensive new unit and Robolectric UI tests. The code review feedback correctly identifies several critical backward compatibility issues that would cause NoSuchMethodError crashes on devices running older Android versions (API < 23 and API < 24) due to unguarded platform calls to Context.getSystemService(Class) and Activity.isInMultiWindowMode. Additionally, the feedback points out a Compose side-effect anti-pattern in the debug overlay where state is mutated directly during composition, providing an actionable suggestion to wrap the update in a LaunchedEffect.

Comment on lines 78 to 80
val surfaceRotation = context.getSystemService(DisplayManager::class.java)
?.getDisplay(Display.DEFAULT_DISPLAY)
?.rotation

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using context.getSystemService(DisplayManager::class.java) directly will crash with a NoSuchMethodError on devices running API levels below 23 (Android 6.0), as Context.getSystemService(Class) was introduced in API 23. Since the Jetpack Camera App supports API 21+, please use context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager to ensure backward compatibility.

    val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager\n    val surfaceRotation = displayManager\n        ?.getDisplay(Display.DEFAULT_DISPLAY)\n        ?.rotation


private fun updateRequestedOrientation(
configuration: Configuration,
multiWindowMode: Boolean = isInMultiWindowMode

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Accessing isInMultiWindowMode as a default parameter value will cause a NoSuchMethodError crash on startup on devices running API levels below 24 (Android 7.0), as Activity.isInMultiWindowMode was introduced in API 24. Please guard this platform call with an API level check.

        multiWindowMode: Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {\n            isInMultiWindowMode\n        } else {\n            false\n        }

val activity = LocalActivity.current ?: remember(context) { context.findDebugActivity() }

var isInMultiWindowMode by remember(activity) {
mutableStateOf(activity?.isInMultiWindowMode ?: false)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Calling activity?.isInMultiWindowMode directly will cause a NoSuchMethodError crash on devices running API levels below 24 (Android 7.0). Please guard this platform call with an API level check to ensure backward compatibility on API 21-23 devices.

        mutableStateOf(\n            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {\n                activity?.isInMultiWindowMode == true\n            } else {\n                false\n            }\n        )

mutableStateOf(activity?.isInMultiWindowMode ?: false)
}
DisposableEffect(activity) {
isInMultiWindowMode = activity?.isInMultiWindowMode ?: false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Calling activity?.isInMultiWindowMode directly inside DisposableEffect will cause a NoSuchMethodError crash on devices running API levels below 24 (Android 7.0). Please guard this platform call with an API level check.

        isInMultiWindowMode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {\n            activity?.isInMultiWindowMode == true\n        } else {\n            false\n        }

Comment on lines +573 to +580
var cumulativeTargetDegrees by remember { mutableFloatStateOf(targetAngle) }
val currentNormalized = ((cumulativeTargetDegrees % 360f) + 360f) % 360f
var delta = targetAngle - currentNormalized
if (delta > 180f) delta -= 360f
if (delta <= -180f) delta += 360f
if (delta != 0f) {
cumulativeTargetDegrees += delta
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Updating cumulativeTargetDegrees directly in the composable body during composition is a side-effect anti-pattern in Jetpack Compose. It can cause unnecessary recompositions or inconsistent state. Please wrap this state update in a LaunchedEffect keyed on targetAngle.

    var cumulativeTargetDegrees by remember { mutableFloatStateOf(targetAngle) }\n    LaunchedEffect(targetAngle) {\n        val currentNormalized = ((cumulativeTargetDegrees % 360f) + 360f) % 360f\n        var delta = targetAngle - currentNormalized\n        if (delta > 180f) delta -= 360f\n        if (delta <= -180f) delta += 360f\n        if (delta != 0f) {\n            cumulativeTargetDegrees += delta\n        }\n    }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant